Skip to content

Snail Mode game mode (BS-cb89aee1)#129

Open
byte-the-bot wants to merge 2 commits into
mainfrom
byte/snail-mode
Open

Snail Mode game mode (BS-cb89aee1)#129
byte-the-bot wants to merge 2 commits into
mainfrom
byte/snail-mode

Conversation

@byte-the-bot

Copy link
Copy Markdown
Collaborator

What

Implements Snail Mode: snakes leave a decaying trail of stacked hazards behind their tail as they move. Ported from the canonical Go community map (maps/snail_mode.go, authored by coreyja and jlafayette) and verified against the real Go source (no Go test file exists — the implementation is the spec, so this PR ships thorough coverage of its own).

Each turn, after the standard pipeline (move / health / hazard damage / feed / eliminate):

  1. Decay — every on-board hazard stack loses one entry. Stacking = repeated Points in board.hazards; standard::damage_hazards already applies damage per entry, so a fresh length-N trail square deals N × hazardDamagePerTurn (14, the Go rules default for map-driven hazards).
  2. Store — each live snake's tail square is recorded for next turn with stack = snake length, unless the tail is doubled (just ate / spawn-stacked: the tail isn't vacating). Eliminated snakes record nothing.
  3. Restore — the previous turn's recorded tails land as on-board stacks, skipping (dropping) squares occupied by a live snake's head.

Food spawns normally, and food on a trail square negates that square's hazard damage (already handled in standard::damage_hazards).

State-representation decision

Pending tails are stored Go-style, as off-board points (y + board.height) inside board.hazards, rather than in a dedicated side-channel field. Reasons:

  • It keeps BoardState fully self-describing, so rules::snail::execute_turn(board, moves, settings) needs no extra mutable state parameter and the trail state flows through every game loop (random runner, live apply_turn loop) for free.
  • It matches Go semantics exactly, including subtle interactions (pending points are present in hazards during the damage phase, where they can only ever match a head that is itself out of bounds and about to be eliminated — same as Go).
  • Persistence check: run_game holds EngineGame in memory for the entire game and never reconstructs board state from persisted frames — a retried GameRunnerJob on a Running game calls reset_game_state_for_retry (wipes turns/placements) and replays from turn 0, and a Finished game short-circuits to idempotent post-completion hooks. So pending-tail state does NOT need to round-trip through frame persistence, and frames stay a write-only feed for the board viewer.

Both serialization boundaries filter through the new BoardState::on_board_hazards() view:

  • wire.rs (/move payloads) — snakes never receive out-of-bounds hazard points (hard requirement; test-enforced).
  • frame.rs (persisted frames / board viewer) — frames carry only real on-board hazards, stacked duplicates included, matching what play.battlesnake.com showed. Off-board bookkeeping points are excluded from frames because nothing reads frames back into engine state and they would only confuse the viewer and any other frame consumer.

Wire-protocol parity (game.map)

On play.battlesnake.com, snail was a map, not a ruleset: snakes saw ruleset.name = "standard" with game.map = "snail_mode". Arena's wire layer already had a map field (always empty). This PR keeps that upstream parity: internally the engine dispatches on meta.ruleset_name == "snail_mode", but the wire sends ruleset.name = "standard" + game.map = "snail_mode", so existing community snakes that key off game.map behave correctly. Other modes are unchanged (map stays "", ruleset name passes through).

Deviations from Go (documented in module docs)

  • Go's doubleTail would panic on a 1-segment snake; this port treats bodies shorter than 2 as doubled (no trail). Unreachable in real games.
  • Go rebuilds hazards via a map-editor with nondeterministic map-iteration order in the decay phase; this port keeps deterministic first-seen order. Hazard multisets are identical.
  • Go's StandardMap.PostUpdateBoard also spawns food; the arena engine spawns food separately (same as every other mode here), so the snail post-update is hazards-only.

Tests

  • rules::snail unit tests: trail appears with stack = snake length one turn after recording; decay by exactly 1/turn/square until gone; double-tail (just ate / spawn-stacked) spawns no trail; eliminated snakes leave no new trail (but their previously recorded trail still lands); head-occupied squares skip placement and the stack is dropped, not deferred; multiple snakes' trails stack independently; per-stack-entry damage (3×14 on a fresh length-3 square) and lethal-stack elimination cause; food negates trail damage; early game-over exit.
  • Worked 5-turn example from the port analysis as an integration-style test (exact on-board multisets per turn).
  • Property tests (proptest, no hardcoded with_cases, no committed seed files): an independent per-square oracle (new = max(old−1,0) + restored·(no live head)), off-board entries exactly equal live non-double-tail snakes' tails × length, full decay without snakes, determinism, and no malformed hazard entries.
  • Server tests: create_initial_game snail settings; live-loop-style frame test (trails appear in frames, off-board points never leak); random-runner smoke test; wire parity tests (standard + snail_mode map, other modes unchanged, off-board filter).

cargo fmt --all, cargo clippy --workspace --all-targets (clean), cargo test -p rules (91 passed), full cargo test -p arena --bin arena (420 passed) all green.

Merge note

This PR trivially conflicts with the parallel Constrictor PR in the server/src/engine/mod.rs dispatch matches (create_initial_game and the per-turn match game.meta.ruleset_name.as_str()): both PRs make the identical refactor and each adds its own arm, so the resolution is a mechanical union of arms.

🤖 Generated with Claude Code

byte-the-bot and others added 2 commits July 14, 2026 22:26
Port of the canonical Go community map (BattlesnakeOfficial/rules
maps/snail_mode.go, by coreyja and jlafayette): snakes leave a decaying
trail of stacked hazards behind their tail as they move.

- rules::snail::post_update_board implements the decay / store / restore
  update; rules::snail::execute_turn runs the standard stages plus the
  post-update, mirroring royale.rs.
- Pending tails are stored Go-style as off-board points (y + height)
  inside board.hazards, keeping BoardState self-describing.
- BoardState::on_board_hazards() gives consumers the filtered view so
  bookkeeping points never leak to snakes or the board viewer.
- Unit tests (trail timing, decay, double-tail, eliminated snakes,
  head-skip, stacked damage, food negation), the worked 5-turn trail
  example, and property tests with an independent per-square oracle.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…d frames

- create_initial_game: GameType::SnailMode arm (internal ruleset_name
  "snail_mode", hazard_damage_per_turn 14 -- the Go rules default for
  map-driven hazards).
- Per-turn dispatch in run_game_with_random_moves and apply_turn now
  matches on game.meta.ruleset_name ("royale" / "snail_mode" / standard
  fallback), covering both the random runner and the live game loop.
- wire.rs: snail games serialize ruleset.name = "standard" with
  game.map = "snail_mode" for play.battlesnake.com parity (community
  snakes key off game.map); off-board pending-tail bookkeeping points
  are filtered so snakes never receive out-of-bounds hazards.
- frame.rs: frames carry only on-board hazards (stacked duplicates
  included); frames are never read back into engine state, so the
  bookkeeping points are excluded from persistence.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant